Skip to content

TypeSchema/TS: make Bundle generic with IR-stored generic params#148

Open
ryukzak wants to merge 17 commits intomainfrom
feat/bundle-generic
Open

TypeSchema/TS: make Bundle generic with IR-stored generic params#148
ryukzak wants to merge 17 commits intomainfrom
feat/bundle-generic

Conversation

@ryukzak
Copy link
Copy Markdown
Collaborator

@ryukzak ryukzak commented Apr 22, 2026

Closes #145.

Summary

Originally scoped to make Bundle<T> generic; expanded into an IR-level refactor so that any specialization schema (top-level or nested) carrying type-family-rooted or generic-bearing fields gets generic params populated on the IR (schema.generic.params), and language writers render directly from that. Profiles intentionally out of scope.

Generated output (before / after)

Before:

export interface Bundle extends Resource {
    resourceType: "Bundle";
    entry?: BundleEntry[];
    // ...
}
export interface BundleEntry extends BackboneElement {
    resource?: Resource;
    response?: BundleEntryResponse;
    // ...
}
export interface BundleEntryResponse extends BackboneElement {
    outcome?: Resource;
    // ...
}

After:

export interface Bundle<T1 extends Resource = Resource, T2 extends Resource = Resource> extends Resource {
    resourceType: "Bundle";
    entry?: BundleEntry<T1, T2>[];
    // ...
}
export interface BundleEntry<T1 extends Resource = Resource, T2 extends Resource = Resource> extends BackboneElement {
    resource?: T1;
    response?: BundleEntryResponse<T2>;
    // ...
}
export interface BundleEntryResponse<T extends Resource = Resource> extends BackboneElement {
    outcome?: T;
    // ...
}

Defaults preserve every existing call site. Callers can narrow via either or both params.

Usage

const bundle: Bundle<Patient | Observation> = {
    resourceType: "Bundle",
    type: "transaction",
    entry: [
        { fullUrl: `urn:uuid:${patient.id}`, resource: patient },
        { fullUrl: `urn:uuid:${obs.id}`, resource: obs },
    ],
};

// TS 5.5+ infers the type predicate from the discriminated union — no explicit `r is Observation` needed
const observations = (bundle.entry ?? [])
    .map(e => e.resource)
    .filter(r => r?.resourceType === "Observation"); // Observation[]

Bundle<Patient | Observation> works because T2 defaults to Resource.

TypeSchema IR change

export type GenericParam = {
    name: string;
    constraint: TypeIdentifier;
    /** The deep field that originally introduced this param. */
    sourceField: string;
};

export type GenericInfo = { params: GenericParam[] };

// SpecializationTypeSchemaBody (Resource, ComplexType, Logical):
generic?: GenericInfo;

// NestedTypeSchema:
generic?: GenericInfo;

sourceField records the deep field that originally introduced the param (the typeFamily-rooted leaf), so the same conceptual param keeps its identity across passthrough hops and parents can align passthrough args even after multi-level inheritance.

Population happens in mkTypeSchemaIndex after populateTypeFamily, iterating to a fixpoint over all specialization schemas (top-level + nested) so order doesn't matter.

Naming policy

  • single param → T
  • multiple params → T1, T2, … positional names

Positional avoids name churn when the underlying field set changes; sourceField on each param keeps deep-origin info available for tooling and for aligning passthrough args.

Writer changes

  • generateType reads schema.generic?.params directly (no local recomputation).
  • Per-field substitution maps (fieldMap, nestedArgsByField) come from the contributions list, aligned to schema params by sourceField.
  • Hardcoded TS specials (Reference<T extends string>, Coding<T extends string>, CodeableConcept<T extends string>) stay in the writer — their constraint is string, not a typeFamily root, and the rendering (template-literal Reference.reference, per-field enum narrowing) is TS-flavored.

Tests

  • typescript.test.ts — assertions and snapshots for BundleEntry<T1, T2>, Bundle<T1, T2>, BundleEntryResponse<T> propagation paths
  • introspection.test.ts — snapshots include the new generic.params IR field on top-level and nested schemas
  • cda.test.tsClinicalDocument now also carries propagated generic params (via fields like Author, Component, etc.)
  • resource.test.ts — demo using Bundle<Patient | Observation> and TS-5.5 type-predicate inference

@ryukzak ryukzak force-pushed the feat/bundle-generic branch from 3868aac to a9cee6f Compare April 28, 2026 14:21
ryukzak and others added 13 commits April 30, 2026 19:27
Parent interfaces now become generic when any of their fields reference
a generic nested type, and the field reference carries the type
parameter through.

Before: `Bundle { entry?: BundleEntry[] }` (always BundleEntry<Resource>)
After:  `Bundle<T extends Resource = Resource> { entry?: BundleEntry<T>[] }`

Default `= Resource` keeps existing call sites working. Callers can now
narrow, e.g. `Bundle<Patient | Observation>`.

Refactored the generic-params computation in generateType() into a
reusable helper (computeGenericInfo) and threaded per-nested-type info
from generateNestedTypes() back into the parent generation.
- Bundle.ts now declares `Bundle<T extends Resource = Resource>` with
  `entry?: BundleEntry<T>[]`.
- Added demo tests showing discriminated-union narrowing via
  `Bundle<Patient | Observation>` and backwards-compat default.
Drop the standalone computeGenericInfo helper and the GenericInfo type
in favor of inlining the (small) logic directly into generateType. The
helper duplicated the existing typeFamilyFields detection that already
worked; the genuinely new bit is just the nested-type pass-through.

generateType now returns the parent's GenericParam[] (only paramList is
needed by callers — fieldMap and nestedArgsByField are local concerns),
and generateNestedTypes threads a Record<string, GenericParam[]> back
to the parent.

Net change vs main: +61/-32 (was +81/-36). No behavior change.
Collapse the two-list collection (typeFamilyFields + nestedFields) into
a single discriminated Contribution[] driven by tsIndex.resolveType,
which handles nested and non-nested identifiers uniformly.

- introduce: field type resolves to a typeFamily root → bind a fresh param
- passthrough: field type resolves to a generic nested type → inherit its params

Extract the collection into a free helper (collectGenericContributions)
to keep generateType under the cognitive-complexity cap. Render pass
becomes one loop over contributions; naming policy (T vs TFieldName)
keys off the contribution shape.
Add `generic.params` to `NestedTypeSchema` (with `GenericParam = { name, constraint: TypeIdentifier }`).
Populate during `mkTypeSchemaIndex` after `populateTypeFamily` — for each nested in URL-sorted order,
collect contributions: introduce (field type is a typeFamily root) or passthrough (field type is a
generic-bearing nested with already-populated `generic.params`).

The TS writer reads `target.generic?.params` directly instead of threading a per-pass
`nestedGenericParams` accumulator. `generateType` and `generateNestedTypes` lose their threading
parameter and `generateType` no longer needs a return value.

Behavior change: this fixes an order-dependency in the old writer where a nested type couldn't see
its sibling nesteds' generic params (because they hadn't been generated yet during the sequential
pass). With the IR populated up front, the writer sees the full picture. As a result, e.g.
`BundleEntry` now passes through `BundleEntryResponse`'s generic, becoming
`BundleEntry<TResource extends Resource = Resource, T extends Resource = Resource>` with
`resource?: TResource; response?: BundleEntryResponse<T>;`. `Bundle<T>` itself stays one-param.
Add `sourceField` to `GenericParam` IR type — the field that originally introduced
the param (preserved through passthrough). Naming policy:
- single param → "T" (short)
- multiple params → `T${UpperFirst(sourceField)}` per param

So a param introduced deep in `BundleEntryResponse.outcome` surfaces in `BundleEntry`
as `TOutcome` rather than the local `T`, regardless of how many carrier hops it took
to get there.

Generator-side change: dedup raw params by `sourceField` (not by name). Writer mirrors
the populator's logic for top-level schemas.

Populator now iterates to a fixpoint instead of one URL-sorted pass — passthrough
between siblings means earlier-processed nesteds don't see later siblings'
introduces. Without fixpoint, BundleEntry's IR-stored `generic.params` would be
stale (one param, missing the response passthrough), causing Bundle (top-level) to
emit wrong arity.

Effect on emit:
  Before: BundleEntry<TResource, T> with response: BundleEntryResponse<T>
  After:  BundleEntry<TResource, TOutcome> with response: BundleEntryResponse<TOutcome>
  Bundle now exposes both: Bundle<TResource, TOutcome> with entry: BundleEntry<TResource, TOutcome>[]
Use positional names (T1, T2, …) for multi-param schemas instead of the
sourceField-derived TResource/TOutcome. Single-param schemas still emit "T".

The IR-stored sourceField is preserved (still used to align passthrough args
across nesting hops), only the rendered name changes.

Effect on emit:
  Before: BundleEntry<TResource, TOutcome>; entry: BundleEntry<TResource, TOutcome>[]
  After:  BundleEntry<T1, T2>;             entry: BundleEntry<T1, T2>[]
@ryukzak ryukzak force-pushed the feat/bundle-generic branch from dd1acc4 to 69a111f Compare May 1, 2026 08:54
@ryukzak ryukzak changed the title TS: make Bundle<T> generic by propagating nested type params TypeSchema/TS: make Bundle generic with IR-stored generic params May 1, 2026
ryukzak added 4 commits May 1, 2026 11:46
- Define `GenericInfo = { params: GenericParam[] }` once and use it on both
  `SpecializationTypeSchemaBody` (Resource, ComplexType, Logical) and
  `NestedTypeSchema`. Symmetric IR surface — any generic-bearing schema carries
  its params, not just nested ones.

- Populator (`populateGeneric`, renamed from `populateNestedGeneric`) iterates
  over a single carrier list of top-level specializations + their nested in
  fixpoint. Profiles are intentionally out of scope — only their nested types
  participate.

- `collectGenericContributions` now treats top-level schemas symmetrically with
  nested: a field whose target carries `generic.params` becomes a passthrough
  regardless of whether the target is top-level or nested.

- TS writer's `generateType` reads `schema.generic?.params` directly (no local
  recomputation of param names). Per-field substitution maps still come from
  the contributions list, aligned to schema params by `sourceField`.

Hardcoded TS specials (`Reference<T extends string>`, `Coding<T extends string>`,
`CodeableConcept<T extends string>`) remain in the writer — their constraint is
a primitive (`string`) and the rendering (template-literal Reference type, enum
narrowing per field) is TS-flavored, not language-neutral IR concerns.

Effect on emit: schemas like `DomainResource` and CDA's `ClinicalDocument` now
also carry their populator-computed generics through to the writer (rather
than being recomputed locally), making the typeFamily-based generic story
fully data-driven from the IR.
`path` is now `string[]` carrying the full origin trail from the schema down to
the typeFamily-rooted field that introduces the param. Single-segment for direct
introduce (e.g. `["outcome"]`), grown by passthrough as the param surfaces
through carrier fields (e.g. `["response", "outcome"]`, then
`["entry", "response", "outcome"]`).

Dedup is by leaf segment of the path — multiple fields with the same deepest
origin still share one generic param (so callers narrow once to update many
fields). Param identity stays the same as before; only the IR data is richer.

Naming policy unchanged: single param → "T", multiple → positional T1/T2/….
Writer alignment also matches by leaf segment of `path` (with fallback to the
child param's typeVar) so passthrough args render correctly across nesting hops.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: make Bundle<T> generic (BundleEntry<T> already is)

1 participant